Skip to content

Wire global model fallback chain on rate/usage limits + reset-aware cooldown - #2271

Open
lsm wants to merge 5 commits into
devfrom
space/wire-global-model-fallback-chain-on-rate-usage-limits
Open

Wire global model fallback chain on rate/usage limits + reset-aware cooldown#2271
lsm wants to merge 5 commits into
devfrom
space/wire-global-model-fallback-chain-on-rate-usage-limits

Conversation

@lsm

@lsm lsm commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Sessions hitting 5h/weekly usage caps (e.g. third-party relay 429 with a reset timestamp) never recovered: the watchdog retried the same model at a fixed 10min ×3 then gave up, and GlobalSettings.fallbackModels/modelFallbackMap had no runtime consumers.

This makes the configured fallback chain real and switches resets to format-agnostic parsing:

  • Fallback chain (A): on 429/usage-cap exhaustion, resolve modelFallbackMap[provider/model] ?? global fallbackModels, switch to the next untried available entry via the existing model-switch machinery, and retry immediately. Repeated 429s advance through the chain; switches are free (don't count toward maxAutoRetries).
  • Reset-aware cooldown (B): when the chain is exhausted, parse any timestamp shape from the error (ISO-8601, YYYY-MM-DD HH:mm:ss incl. the Chinese relay message, epoch s/ms) and wait until reset + buffer; otherwise use a backoff ladder (10m→4h, cap 8h, jitter). Reset-known waits don't count toward the retry budget.
  • Task status (C): a paused Space worker task surfaces rate_limited/usage_limited with a restrictions.resetAt blob (migration 163) and auto-resumes to in_progress when the limit lifts.

Pure recovery logic lives in a new fallback-recovery.ts module (fully unit-tested); the watchdog is refactored to take injected deps. query-runner.ts/model-switch-handler.ts are unchanged — the existing skip-error-broadcast gate already prevents task failure on recovery.

lsm added 2 commits July 27, 2026 13:11
Sessions hitting rate/usage caps (e.g. third-party relay 429 with a reset
timestamp) never recovered: the watchdog retried the same model at a fixed
10min x3 then gave up, and GlobalSettings.fallbackModels/modelFallbackMap had
no runtime consumers.

- New pure fallback-recovery module: chain resolution (map override vs global),
  next-entry selection (skip tried / same-model / unavailable), format-agnostic
  reset-timestamp extraction (ISO-8601, YYYY-MM-DD HH:mm:ss incl. the Chinese
  relay shape, epoch s/ms), and a backoff ladder (10m->4h, cap 8h) with jitter.
- RateLimitWatchdog drives two-phase recovery: (A) immediate fallback-model
  switch via injected deps (free, tracked per-episode), then (B) a cooldown at
  the parsed reset time or on the backoff ladder. Reset-known waits don't count
  toward maxAutoRetries.
- AgentSession wires the watchdog to settings (chain), the provider registry
  (availability), model-switch (switch+retry after the failed query's finally),
  and the internal event bus (pause/resume).
- Add session.rate_limit_pause / session.rate_limit_resume events.
…-resume

With the fallback chain wired (previous commit), a 429 no longer fails a Space
worker task — the error broadcast is skipped so the session recovers or waits.
This commit adds the visible status: when a worker session pauses on a cap with
no fallback left, mark its task rate_limited / usage_limited with a resume-at
restriction, and restore it to in_progress when the limit lifts.

- Migration 163 widens space_tasks.status CHECK (rate_limited, usage_limited)
  and adds a nullable restrictions column; test-DB helper kept in parity.
- SpaceTaskStatus + SpaceTask.restrictions (TaskRestriction) in shared types;
  repo reads/writes the JSON blob.
- TaskAgentManager subscribes to session.rate_limit_pause/resume and maps the
  session to its parent task, setting/clearing the paused status.
- Web status maps (labels, badges, transitions) cover the two new statuses.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@lsm lsm left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Review by glm-5.1 (NeoKai)

Model: glm-5.1 | Client: NeoKai | Provider: GLM

Recommendation: REQUEST_CHANGES

Reviewed from scratch: full diff, all 17 changed files, the integration points (query-runner.ts, model-switch-handler.ts, processing-state-manager.ts), and the surrounding space-runtime tick/rehydrate paths. Ran the new/changed unit suites (53 + 70, all green) and bun run check (lint/typecheck/knip/parity/session-guards/test-quality — all clean). The coder's verification claims hold.

What's solid. The pure recovery module (fallback-recovery.ts) is well-factored and correctly handles the Chinese relay shape (2026-07-22 17:55:10 via LOCAL_DATETIME_RE, the [1308] code correctly ignored), the backoff ladder, and the freeWait semantics. The concurrency core is sound: switchAndRetryForFallback correctly await this.queryPromise so the failed query's finally (null queryObject, env restore, setIdle) completes before handleModelSwitch runs — I traced no double-query / dedup / late-finally race, and cross-provider switching works (config-only branch swaps provider + clears sdkSessionId). Migration 163 follows the established M98/M162 table-rebuild pattern (FK-off, column copy, idempotent guard) and is safe. The onMarkApiSuccess → reset() → notifyResume resume path is correctly wired.

The findings below are real and actionable but none are crash/incorrect-output bugs — they concern reliability of the feature's headline guarantee and test coverage of its central invariants.


P2-1 — Auto-resume does not survive a daemon restart (the motivating use case)

The cooldown is an in-memory unref'd setTimeout (rate-limit-watchdog.ts:295). The persisted restrictions.resetAt blob (migration 163 + repo round-trip) is written on pause but never read on startup. The space-runtime tick loop only drives open/in_progress/blocked tasks (space-runtime.ts:4066), so a usage_limited/rate_limited task is never re-driven, and rehydrating a session constructs a fresh watchdog with no timer/retryCount.

Consequence: for a 5-hour or weekly cap — the exact scenario this PR exists to fix — a daemon restart during the wait leaves the task paused indefinitely with no auto-resume and no error. This is the same "persisted data with no runtime consumer" anti-pattern this PR removes for fallbackModels; the new resetAt repeats it. The manual Resume button (TaskStatusActions.tsx: rate_limited->in_progress) is an escape hatch, so it is recoverable, not a hard-stuck — but the "auto-resumes after reset" criterion (Part C / VERIFICATION) is unmet across restarts, and a paused task with a future resetAt that nobody arms is misleading in the UI.

Fix options: on daemon/workflow start, scan paused tasks — those with resetAt in the past → restore in_progress + clear restrictions; those still future → re-arm a runtime-level scheduled restore (or re-arm the watchdog cooldown on rehydrate). At minimum, explicitly document that auto-resume requires daemon uptime. The persisted resetAt should either be consumed or not persisted as a resume promise.

P2-2 — fireImmediateFallback has no error boundary; a rejection sticks fallbackPending = true

rate-limit-watchdog.ts:321-339 is void-fired from scheduleRetry:239 with no try/catch. switchAndRetryForFallback has a catch-all, but its own catch calls this.stateManager.setIdle() which can throw (DB write); the recursive await this.scheduleRetry(...) at :337 can also reject. If anything escapes, the promise rejects unhandled AND this.fallbackPending is never reset (:326 is skipped), so getState() reports 'fallback-pending' forever and retryNow() (:365) hard-returns false — a stuck-visible state with no recovery short of reset(). Wrap the body in try/catch: on error, log, clear fallbackPending, mark the entry tried, and fall through to a cooldown.

P2-3 — Untested load-bearing invariants

Three behaviors central to this feature have no test, so they will silently regress:

  • Parsed-reset waits bypass maxAutoRetries. This is the design guarantee ("reset-known waits don't count toward the budget"). The only exhaustion test (rate-limit-watchdog.test.ts:159) uses '429' (no timestamp) and asserts false; nothing asserts that with retryCount === maxAutoRetries + a parseable ISO reset, scheduleRetry returns true and retryCount stays put.
  • Late resume does not resurrect a cancelled/done task. Explicitly in the review checklist. The source guard (restoreTaskFromRateLimit:610) is correct, but the only resume tests cover usage_limited→in_progress (:99) and the in_progress no-op (:141); nothing publishes session.rate_limit_resume against a cancelled/done/archived task. The terminal-override test (:129) only covers done on the pause side.
  • resetAt buffer arithmetic on the notifyPause payload is asserted nowhere (watchdog emits decision.retryAtMs = reset + 30s, :285); a regression dropping the buffer passes every test.

P3 — optional polish (not blocking on their own)

  • rate-limit-watchdog.ts:283 fires notifyPause before setRateLimitCooldown (:289); reorder so the processing-state flip precedes the bus event and avoids a brief idle window where onIdleCallback runs.
  • rate-limit-watchdog.ts:237 comment says scheduleRetry "must return true synchronously" — it is awaited at query-runner.ts:1314; rephrase to "must resolve to true".
  • fallback-recovery.ts:301-312 classifyLimitKind keywords are broad ('exceeded', 'limit reached') and any parsed timestamp → usage_limit, so a transient "rate limit exceeded, retry in 60s" is labelled a daily/weekly cap. Label-only impact (both resume identically), but worth narrowing.
  • A manual status transition out of rate_limited/usage_limited via setTaskStatus (not the resume path) leaves a stale restrictions blob; clear it on exit. No frontend consumer yet, latent.
  • VALID_SPACE_TASK_TRANSITIONS omits rate_limited/usage_limited → archived (every other non-terminal status can archive). Likely intentional; confirm.

Happy to re-review once P2-1 through P2-3 are addressed.

Comment thread packages/daemon/src/lib/agent/rate-limit-watchdog.ts
Comment thread packages/daemon/src/lib/agent/rate-limit-watchdog.ts Outdated
Comment thread packages/daemon/tests/unit/1-core/agent/rate-limit-watchdog.test.ts
Comment thread packages/daemon/tests/unit/5-space/runtime/task-agent-rate-limit-listener.test.ts Outdated
…ry, invariant tests

P2-1 auto-resume across daemon restart: the in-memory watchdog cooldown doesn't
survive a restart, so add a SpaceRuntime tick sweep (recoverRateLimitedTasks)
that restores rate/usage-limited tasks whose persisted restrictions.resetAt has
passed to in_progress (clearing restrictions), driven off the persisted blob so
the normal rehydration restarts the worker. Tasks with a future resetAt stay
paused. New repo helper listRateLimitedBySpace.

P2-2 error boundary: wrap fireImmediateFallback in try/catch so a rejecting
switchAndRetry or recursive scheduleRetry can't leave fallbackPending stuck
true (which froze getState/retryNow). On error: clear fallbackPending, mark
the entry tried, fall through to a cooldown.

P2-3 invariant tests: parsed-reset wait bypasses maxAutoRetries (true +
retryCount unchanged at the budget); 30s buffer pinned on notifyPause resetAt;
late resume does not resurrect cancelled/done/archived; pause-side terminal
guard covers done/blocked/cancelled/archived; cross-restart sweep test.

P3 polish: setRateLimitCooldown before notifyPause; fix 'return true
synchronously' comment; narrow classifyLimitKind keywords (drop 'exceeded'/
'limit reached' that mislabelled transient rate limits); auto-clear stale
restrictions on manual transition out of the paused statuses; add
rate_limited/usage_limited → archived transitions + labels.

@lsm lsm left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Review by glm-5.1 (NeoKai)

Model: glm-5.1 | Client: NeoKai | Provider: GLM

Recommendation: APPROVE

Round 2 (fresh, commit 755f340). All three P2 findings and the P3 polish are correctly addressed; I re-verified each on its merits rather than trusting the summary.

P2-1 (cross-restart auto-resume) — fixed. SpaceRuntime.recoverRateLimitedTasks() runs in the tick loop off the persisted restrictions.resetAt: past-reset → restore in_progress + clear restrictions (worker re-driven by the normal in_progress rehydrate/recoverStalledRuns path, which has separate coverage); future-reset → left paused. listRateLimitedBySpace + the repo's auto-clear of stale restrictions on any exit transition back it up. The only residual — a backoff-path rate_limited task carries a synthetic resetAt ≈ now+1h rather than the exact ladder delay — is safe: usage caps (the long-window case) always carry an accurate parsed reset, and a transient rate limit retrying slightly early is harmless. Not worth blocking.

P2-2 (error boundary) — fixed. fireImmediateFallback now wraps switchAndRetry in try/catch with a finally that always clears fallbackPending, and guards the recursive scheduleRetry with a best-effort cooldown fallback. No path leaves fallbackPending stuck or getState()/retryNow() frozen.

P2-3 (invariant tests) — fixed, and the assertions are meaningful: parsed-reset wait bypasses maxAutoRetries (returns true with retryCount unchanged at the budget); the 30s RESET_BUFFER_MS is pinned on the notifyPause payload; late resume does not resurrect cancelled/done/archived and confirms the restrictions auto-clear; pause-side terminal guard broadened to done/blocked/cancelled/archived.

P3 — all applied: setRateLimitCooldown before notifyPause; "must resolve to true" comment; classifyLimitKind narrowed (dropped the over-broad exceeded/limit reached/达到; the Chinese relay still classifies correctly via 上限/小时 and the parsed-reset path); rate_limited/usage_limited → archived transitions + labels.

Verification: bun run check clean (lint/typecheck/knip/parity/session-guards/test-quality); changed suites green (watchdog+fallback 55, listener+tick-loop+repo 125); all 4 prior threads resolved; PR open and mergeable. No new issues or regressions introduced by the fix commit. Ship it.

@lsm

lsm commented Jul 30, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bc71f95761

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +4652 to +4655
// Auto-resume tasks paused on a rate/usage cap whose reset has passed.
// Driven off the persisted `restrictions.resetAt`, so it survives daemon
// restarts (the in-memory watchdog cooldown does not).
await this.recoverRateLimitedTasks();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve paused tasks before processing workflow liveness

On a daemon restart while restrictions.resetAt is still in the future, TaskAgentManager.rehydrate() excludes the paused task, but this tick calls processCompletedTasks() before this recovery sweep. Consequently, processRunTick() sees the persisted in_progress node execution without a live session, classifies it as crashed, resets it to pending, and respawns it; this sweep then skips the task because its reset time has not passed. The worker therefore resumes immediately while the task remains visibly rate-limited, bypassing the intended cooldown. Gate liveness/spawning for these paused statuses or handle them before processing runs.

Useful? React with 👍 / 👎.

Comment on lines +458 to +462
const ok = await Promise.resolve(p.isAvailable());
if (!ok) return false;
if (typeof p.getAuthStatus === 'function') {
const auth = await p.getAuthStatus();
return auth.isAuthenticated;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the provider's runtime availability result

When a fallback targets anthropic-copilot authenticated through an environment variable, gh CLI, or hosts.yml, isAvailable() correctly returns true, but getAuthStatus() intentionally checks only HyperNeo-managed auth.json credentials (anthropic-copilot/provider.ts). Requiring both results makes an otherwise usable configured fallback appear unavailable, so the watchdog skips it and enters cooldown. Treat the successful runtime availability check as authoritative rather than additionally applying the UI-only authentication status.

Useful? React with 👍 / 👎.

Comment on lines +597 to +601
restrictions: {
type: status === 'usage_limited' ? 'usage_limit' : 'rate_limit',
limit: reason,
resetAt: resetAt ?? Date.now() + 60 * 60 * 1000,
sessionRole: 'worker',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist the actual backoff retry time

For backoff-ladder cooldowns, notifyPause supplies no resetAt, so this stores an arbitrary one-hour deadline even though the watchdog's first delays are 10 and 30 minutes. If the daemon restarts during either step, the in-memory timer is lost and recoverRateLimitedTasks() trusts this persisted value, delaying recovery until an hour after the error. Persist the cooldown decision's actual retryAtMs for backoff waits instead of substituting a fixed hour.

Useful? React with 👍 / 👎.

Comment on lines 376 to +379
cancel(): void {
this.cancelCooldownTimer();
this.fallbackPending = false;
this.notifyResume();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Invalidate an in-flight fallback when recovery is cancelled

When the user sends a new message or resets the session while fireImmediateFallback() is awaiting query teardown/model switching, cancel() only clears the flag and timer; it does not stop or invalidate that asynchronous operation. The fallback can therefore finish afterward, switch the reset session's provider, and re-enqueue the previously failed message alongside the user's new work. Track an episode generation or cancellation token and check it before switching and before re-enqueueing.

Useful? React with 👍 / 👎.

Comment on lines +61 to +62
rate_limited: ['in_progress', 'open', 'cancelled', 'archived'],
usage_limited: ['in_progress', 'open', 'cancelled', 'archived'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route paused-task actions through workflow lifecycle handling

For a workflow-backed paused task, these advertised manual transitions go through the ordinary setTaskStatus() path because isWorkflowRecoveryTransition() does not recognize either new source status, and the RPC's workflow-stop condition only recognizes in_progress/blocked. As a result, Resume merely changes the task row while the session remains in cooldown, and Cancel leaves the workflow/session running so the watchdog can later retry work on a cancelled task. The paused-source transitions need to cancel/retry the owning session and recover or stop the linked workflow rather than only updating status.

Useful? React with 👍 / 👎.

Comment on lines +411 to +412
| 'rate_limited'
| 'usage_limited';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep rate-limited tasks in a visible task group

After a task enters either new status, it disappears from every Tasks tab: SpaceTasks.TAB_PREDICATES and TAB_GROUPS_DEF only partition draft, open/in-progress/approved, review/blocked, and terminal statuses, while the shared sidebar predicates omit these statuses as well. The new labels in the detail panes only help if the user already has a direct route open; normal task navigation and badge counts no longer expose the paused task or its manual recovery actions. Add both statuses to an appropriate task grouping and its shared predicates.

Useful? React with 👍 / 👎.

Comment on lines +193 to +199
// 1. ISO-8601 with timezone.
const iso = errorMessage.match(ISO_WITH_TZ_RE);
if (iso) {
const ms = parseIsoWithTzGroups(iso);
if (isValidReset(ms, now)) {
return { resetAtMs: ms, strategy: 'iso8601' };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Continue scanning after an invalid timestamp candidate

When an error contains more than one timestamp in the same format—such as a past request timestamp followed by a future quota reset—match() returns only the first token. If that token fails isValidReset, the parser never examines later ISO candidates and falls through to backoff even though a valid reset is present; the same problem affects the epoch patterns below. Iterate all candidates for each strategy until a plausible future reset is found.

Useful? React with 👍 / 👎.

Comment on lines +875 to +877
// (3) Re-enqueue with the new model and start a fresh query.
await this.executeRateLimitAutoRetry(lastUserMessage);
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report a failed fallback retry as unsuccessful

If the model switch succeeds but startQueryAndEnqueue() fails—for example because the replacement query did not start—executeRateLimitAutoRetry() catches that exception internally and returns normally, so this method reports true. The watchdog then treats the fallback as successful and stops advancing the chain or scheduling a cooldown, leaving the failed message idle with no recovery pending. Return a success value from the retry helper or let the failure propagate before returning true.

Useful? React with 👍 / 👎.

Comment on lines +586 to +593
// Don't override a terminal status the runtime has already decided on.
if (
task.status === 'done' ||
task.status === 'cancelled' ||
task.status === 'archived' ||
task.status === 'blocked'
) {
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the pre-limit task lifecycle state

A rate-limited sub-session can belong to a task currently in review or approved—notably a post-approval executor—but this guard allows those states to be overwritten with a limited status, and restoreTaskFromRateLimit() later always restores in_progress. That loses the approval/review lifecycle state and can bypass or abandon the pending post-approval route when the cooldown ends. Either restrict task-level pausing to tasks already in progress or persist and restore the exact prior status.

Useful? React with 👍 / 👎.

Comment on lines +8999 to +9002
await this.updateTaskAndEmit(space.id, task.id, {
status: 'in_progress',
restrictions: null,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reacquire a task slot before auto-resuming

Paused statuses are excluded from getRunningTaskCount(), so while a task waits on a cooldown the runtime can use its freed maxConcurrentTasks slot to start another task. This unconditional cross-restart resume then marks the paused task in progress and restarts its worker without checking capacity, allowing the space to exceed its configured concurrency limit; the in-memory resume listener has the same unconditional behavior. Queue the resume until a slot is available or continue counting paused tasks against the limit.

Useful? React with 👍 / 👎.

The migration-marker runner tests seed a minimal space_tasks sentinel (no
status CHECK) to verify migrations are idempotent on bare schemas. runMigration163
threw 'space_tasks status CHECK constraint not found' there, failing
4-space-migrations-b in CI.

Align with M103's guard: only run the CHECK-widening rebuild when the live DDL
actually contains 'status IN ('; otherwise skip the rebuild and just add the
nullable restrictions column via ALTER (idempotent). Real pipeline-created
space_tasks tables have the CHECK, so the rebuild still runs there; bare
sentinel schemas get the column without the throw.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e05f3c8620

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +563 to +565
const taskId = this.findParentTaskIdForSubSession(event.sessionId);
if (!taskId) return;
void this.restoreTaskFromRateLimit(taskId).catch((err) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wait for every limited sub-session before restoring

When two parallel node-agent sessions belonging to the same task enter cooldown, the first session.rate_limit_resume event unconditionally restores the parent task and clears the single restriction even if the other session is still limited. This hides the remaining cooldown and, if the daemon restarts before it expires, removes the persisted reset deadline so the still-limited worker can be restarted immediately. Track limited session IDs per task and restore only after the last one resumes.

Useful? React with 👍 / 👎.

Comment on lines +411 to +412
| 'rate_limited'
| 'usage_limited';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat limited goal tasks as active

When a goal-owned task enters either new status, goal-service.ts:isActiveTaskStatus() still recognizes only open, in_progress, review, and approved. Consequently, createImmediateTaskInternal() or the scheduled-task claim path can clear the paused task from goal.activeTaskId and create or claim another task; the original task later auto-resumes, leaving two concurrent tasks for a goal whose active-task pointer is meant to enforce a single run. Include both limited statuses in the active-task predicate.

Useful? React with 👍 / 👎.

Comment on lines +421 to +423
this.lastErrorMessage = '';
this.triedKeys.clear();
this.chain = null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve fallback episodes through initialization messages

QueryRunner.handleSDKMessage() invokes onMarkApiSuccess() for every SDK frame, including the initialization frame emitted when a fallback query starts, and this newly clears the tried-entry set and resolved chain. If fallback A then returns another 429, its episode has already been forgotten; after switching to B and receiving another initialization frame, B can select A again, producing an unbounded A/B fallback loop instead of exhausting the chain and entering cooldown. Reset the episode only after a meaningful successful turn, not initialization or error-result frames.

Useful? React with 👍 / 👎.

Comment on lines +208 to +210
// Mark the CURRENT (failed) provider+model as tried so we never re-select it.
const { provider, model } = this.deps.getCurrentModel();
this.triedKeys.add(entryKey({ provider, model }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Compare fallback candidates after alias resolution

The failed model is marked tried using its raw configured ID, while fallback entries also use raw IDs. For a session configured with an alias such as sonnet and a fallback entry containing the canonical ID for that same model, the entry is considered new; ModelSwitchHandler resolves both IDs, reports an already-current model as a successful switch, and leaves the alias in session config. Every subsequent 429 therefore selects the same canonical entry again and retries immediately forever. Deduplicate the current model and candidates using provider-aware resolved IDs.

Useful? React with 👍 / 👎.

Comment on lines +439 to +442
getCurrentModel: () => ({
provider: (this.session.config.provider as string | undefined) ?? 'anthropic',
model: this.session.config.model ?? 'sonnet',
}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize legacy providers before fallback switching

For persisted sessions created before explicit provider IDs were stored, QueryRunner deliberately treats a missing provider as Anthropic, and this recovery code likewise resolves the chain as Anthropic. However, the actual fallback calls ModelSwitchHandler, which rejects immediately when session.config.provider is absent, so every configured fallback is reported as a failed switch and the legacy session falls through to cooldown. Persist or pass the inferred Anthropic provider before attempting the switch.

Useful? React with 👍 / 👎.

Comment on lines +595 to +603
this.config.taskRepo.updateTask(taskId, {
status,
restrictions: {
type: status === 'usage_limited' ? 'usage_limit' : 'rate_limit',
limit: reason,
resetAt: resetAt ?? Date.now() + 60 * 60 * 1000,
sessionRole: 'worker',
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit task updates for pause and resume

These pause/resume paths write directly through taskRepo.updateTask() without publishing space.task.updated. The web task collections in packages/web/src/lib/space-store.ts are loaded by RPC and synchronized through that event rather than a task-list LiveQuery, so an already-connected client keeps showing the prior status and never receives the new restriction or its later removal until another refresh. Publish the updated task through the same event path used by SpaceRuntime.updateTaskAndEmit() after both writes.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant